"""
Combination generation system for the data preprocessing pipeline.

This module provides functionality to generate all possible combinations of preprocessing
methods across multiple layers using Cartesian product calculations.
"""

import itertools
from typing import List, Set, Tuple
from hashlib import md5


class CombinationGenerator:
    """
    Generates all possible combinations of preprocessing methods across layers.
    
    This class handles the creation of preprocessing combinations using Cartesian product
    calculations, generates unique identifiers for each combination, and provides
    validation and duplicate detection capabilities.
    """
    
    def __init__(self):
        """Initialize the combination generator."""
        self._generated_combinations: Set[Tuple[int, ...]] = set()
        self._combination_ids: Set[str] = set()
    
    def generate_combinations(self, layer_selections: List[List[int]]) -> List[List[int]]:
        """
        Generate all possible combinations using Cartesian product.
        
        Takes a list of layer selections where each layer contains a list of selected
        preprocessing method IDs, and generates all possible combinations by computing
        the Cartesian product across all layers.
        
        Args:
            layer_selections (List[List[int]]): List of preprocessing method IDs for each layer.
                                              Each inner list contains the selected method IDs for that layer.
        
        Returns:
            List[List[int]]: List of all possible combinations, where each combination
                           is a list of preprocessing method IDs (one per layer).
        
        Raises:
            ValueError: If layer_selections is empty or contains empty layers.
        """
        if not layer_selections:
            raise ValueError("Layer selections cannot be empty")
        
        if any(not layer for layer in layer_selections):
            raise ValueError("Each layer must have at least one preprocessing method selected")
        
        # Clear previous state
        self._generated_combinations.clear()
        self._combination_ids.clear()
        
        # Generate all combinations using Cartesian product
        combinations = []
        for combination_tuple in itertools.product(*layer_selections):
            combination = list(combination_tuple)
            
            # Validate and add combination
            if self._is_valid_combination(combination):
                combinations.append(combination)
                self._generated_combinations.add(combination_tuple)
        
        return combinations
    
    def create_combination_id(self, combination: List[int]) -> str:
        """
        Create a unique identifier for a preprocessing combination.
        
        Generates a unique string identifier for a given combination of preprocessing
        methods. The ID is based on the combination content and ensures uniqueness
        across all generated combinations.
        
        Args:
            combination (List[int]): List of preprocessing method IDs representing a combination.
        
        Returns:
            str: Unique identifier string for the combination.
        
        Raises:
            ValueError: If the combination is empty or invalid.
        """
        if not combination:
            raise ValueError("Combination cannot be empty")
        
        if not all(isinstance(method_id, int) and method_id >= 0 for method_id in combination):
            raise ValueError("All method IDs must be non-negative integers")
        
        # Create a deterministic ID based on combination content
        combination_str = "_".join(map(str, combination))
        
        # Use MD5 hash for shorter, unique IDs while maintaining determinism
        hash_object = md5(combination_str.encode())
        base_id = hash_object.hexdigest()[:8]  # Use first 8 characters for brevity
        
        # Ensure uniqueness by adding suffix if needed
        unique_id = base_id
        counter = 1
        while unique_id in self._combination_ids:
            unique_id = f"{base_id}_{counter}"
            counter += 1
        
        self._combination_ids.add(unique_id)
        return unique_id
    
    def validate_combination(self, combination: List[int]) -> bool:
        """
        Validate a preprocessing combination.
        
        Checks if a combination is valid by ensuring all method IDs are non-negative
        integers and the combination structure is correct.
        
        Args:
            combination (List[int]): List of preprocessing method IDs to validate.
        
        Returns:
            bool: True if the combination is valid, False otherwise.
        """
        if not combination:
            return False
        
        if not all(isinstance(method_id, int) and method_id >= 0 for method_id in combination):
            return False
        
        return True
    
    def has_duplicates(self, combinations: List[List[int]]) -> bool:
        """
        Check if there are duplicate combinations in the provided list.
        
        Args:
            combinations (List[List[int]]): List of combinations to check for duplicates.
        
        Returns:
            bool: True if duplicates are found, False otherwise.
        """
        seen = set()
        for combination in combinations:
            combination_tuple = tuple(combination)
            if combination_tuple in seen:
                return True
            seen.add(combination_tuple)
        return False
    
    def remove_duplicates(self, combinations: List[List[int]]) -> List[List[int]]:
        """
        Remove duplicate combinations from the provided list.
        
        Args:
            combinations (List[List[int]]): List of combinations that may contain duplicates.
        
        Returns:
            List[List[int]]: List of unique combinations with duplicates removed.
        """
        seen = set()
        unique_combinations = []
        
        for combination in combinations:
            combination_tuple = tuple(combination)
            if combination_tuple not in seen:
                seen.add(combination_tuple)
                unique_combinations.append(combination)
        
        return unique_combinations
    
    def get_combination_count(self, layer_selections: List[List[int]]) -> int:
        """
        Calculate the total number of possible combinations without generating them.
        
        This method provides a quick way to determine how many combinations would be
        generated from the given layer selections, useful for performance warnings.
        
        Args:
            layer_selections (List[List[int]]): List of preprocessing method IDs for each layer.
        
        Returns:
            int: Total number of possible combinations.
        
        Raises:
            ValueError: If layer_selections is empty or contains empty layers.
        """
        if not layer_selections:
            raise ValueError("Layer selections cannot be empty")
        
        if any(not layer for layer in layer_selections):
            raise ValueError("Each layer must have at least one preprocessing method selected")
        
        # Calculate Cartesian product size
        total_combinations = 1
        for layer in layer_selections:
            total_combinations *= len(layer)
        
        return total_combinations
    
    def _is_valid_combination(self, combination: List[int]) -> bool:
        """
        Internal validation method for combinations.
        
        Args:
            combination (List[int]): Combination to validate.
        
        Returns:
            bool: True if valid, False otherwise.
        """
        return self.validate_combination(combination)
    
    def calculate_total_combinations(self, layer_selections: List[List[int]]) -> int:
        """
        Calculate the total number of possible combinations.
        
        This is an alias for get_combination_count for backward compatibility.
        
        Args:
            layer_selections (List[List[int]]): List of preprocessing method IDs for each layer.
        
        Returns:
            int: Total number of possible combinations.
        """
        return self.get_combination_count(layer_selections)
    
    def clear_cache(self):
        """
        Clear the internal cache of generated combinations and IDs.
        
        This method resets the internal state, allowing for fresh combination
        generation without memory of previous operations.
        """
        self._generated_combinations.clear()
        self._combination_ids.clear()